![[為你自己寫 Vue Component] AtomicAccordion / AtomicCollapse](https://ithelp.ithome.com.tw/upload/images/20240917/20120484eJmsMq561S.png)
看這個元件的名稱不難發現,它的靈感來自手風琴(Accordion)這個樂器。在網頁裡面,Accordion 算是一個常見的設計。它是一個垂直堆疊的標題列表,當點擊時會顯示或隱藏內部相關的內容。在 UX 上,比起一次性將冗長的內容展示給使用者,Accordion 可以循序漸進地向使用者傳遞他們應該關注或感興趣的細節。此外,由於它減少了使用者所需的捲動量,可以有效降低使用者使用網頁時的滾動疲勞。
在不同的 UI Library 中,對於這個元件的名稱選用有所不同。例如在 Element Plus 中,他們將這個元件稱為 Collapse,在 Vuetify 中,稱為 Expansion Panels,而在 Nuxt UI、PrimeVue、Radix Vue 中則稱為 Accordion。
更深入一點去探究,Element Plus 中的 Collapse 預設可以同時展開多個面板,如果想要一次只能開一個面板,需要設定 accordion 屬性為 true。而 Vuetify 的 Expansion Panels 與 Nuxt UI、PrimeVue、Radix Vue 的 Accordion,預設是一次只能展開一個面板,如果想要同時展開多個面板,需要設定 multiple 屬性為 true。
總結以上,我們可以簡單得出結論:
在這篇文章中,我們選用 <AtomicAccordion> 作為元件的名稱。

1 + 2 我們統稱為 Panel,Panel 是 Accordion 的基本單位,一個 Accordion 可以有多個 Panel。
在開始實作前,我們先研究各個 UI Library 的 Accordion / Collapse 元件是如何設計的。
Element Plus

<template>
  <ElCollapse v-model="activeName">
    <ElCollapseItem title="Consistency" name="1">
      <div>
        Consistent with real life: in line with the process and logic of real
        life, and comply with languages and habits that the users are used to;
      </div>
      <div>
        Consistent within interface: all elements should be consistent, such
        as: design style, icons and texts, position of elements, etc.
      </div>
    </ElCollapseItem>
    <ElCollapseItem title="Feedback" name="2">
      <div>
        Operation feedback: enable the users to clearly perceive their
        operations by style updates and interactive effects;
      </div>
      <div>
        Visual feedback: reflect current state by updating or rearranging
        elements of the page.
      </div>
    </ElCollapseItem>
  </ElCollapse>
</template>
Element Plus 提供了兩個元件組合使用,<ElCollapse> 與 <ElCollapseItem>,前者是容器,後者是可展開的 Panel。我們可以在 <ElCollapse> 上使用 v-model 來控制哪個 Panel 是展開的,如果並不關注當前展開的面板是哪一個,可以不使用 v-model。
而如果我們希望提供預設展開的面板,可以這樣使用 model-value="1",這樣在畫面渲染時 name 為 '1' 的面板就會是展開的狀態。
另外,Element Plus 的 Collapse 預設同時能展開多個面板,如果想要一次只展開一個面板,需要設定 accordion 屬性為 true。
Vuetify

<template>
  <VExpansionPanels
    v-model="panel"
    :disabled="disabled"
    multiple
  >
    <VExpansionPanel title="Panel 1" text="Some content" />
    <VExpansionPanel title="Panel 2" text="Some content" />
    <VExpansionPanel title="Panel 3" text="Some content" />
  </VExpansionPanels>
</template>
Vuetify 選用了很特別的元件命名,<VExpansionPanel>,並提供有 <VExpansionPanels> 作為控制用的容器。<VExpansionPanels> 預設為單選模式,如果要同時展開多個面板,需要加上 multiple 屬性。
如果想要更細緻的客製化,可以使用 <VExpansionPanelTitle>  與 <VExpansionPanelText> 來自訂 Panel 的標題與內容。
<template>
  <VExpansionPanels
    v-model="panel"
    :disabled="disabled"
    multiple
  >
    <VExpansionPanel>
      <VExpansionPanelTitle>
        Panel 1
      </VExpansionPanelTitle>
      <VExpansionPanelText>
        Some content
      </VExpansionPanelText>
    </VExpansionPanel>
  </VExpansionPanels>
</template>
Nuxt UI

<template>
  <UAccordion :items="items" multiple />
</template>
Nuxt UI 在使用方式的設計上,只需要一個元件搭配正確的資料就可以完成需求。這樣的設計在簡單場景下非常方便,但如果想要客製化的話則需要使用 slots 自訂所需部分,程式碼也會變得更複雜。
綜合以上並結合自身經驗,我們統整出 <AtomicAccordion> 的功能:
<AtomicAccordion> 與 <AtomicAccordionPanel> 兩個元件組合使用,前者是容器,後者是可展開的 Panel。v-model,有使用則為受控元件,沒有使用則為非受控元件。model-value="1" 的方式來設定預設展開的面板,但元件仍然為非受控元件。multiple 屬性,可以同時展開多個面板,如果使用 v-bind:multiple="true" 則 modelValue 需要傳入陣列。使用結構如下:
<template>
  <AtomicAccordion>
    <AtomicAccordionPanel value="1">
      <template #summary>
        短歌行
      </template>
      對酒當歌,人生幾何?譬如朝露,去日...(略)
    </AtomicAccordionPanel>
  </AtomicAccordion>
</template>
首先我們先將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
<AtomicAccordion>
| 名稱 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| modelValue | string,string[],number,number[] | 控制哪個 Panel 是展開的 | |
| multiple | boolean | false | 是否可以同時展開多個面板 | 
<AtomicAccordionPanel>
| 名稱 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| value | string,number,undefined | undefined | Panel 的名稱 | 
<AtomicAccordion>)因為我們需要支援 multiple 模式,啟用 multiple 模式時的 modelValue 要是一個陣列,反之則需要使用 string 或是 number。為了方便內部實作,我們可以把資料轉換成陣列的形式,這樣在往後的實作可以減去非常多繁瑣的判斷。
const toArray = (value: any) => Array.isArray(value) ? value : [value];
const modelValueWritable = computed({
  get() {
    return toArray(props.modelValue);
  },
  set(value) {
    emit('update:modelValue', props.multiple ? value : value[0]);
  },
});
不過我們既然想讓元件兼容受控與非受控模式,我們就需要讓 <AtomicAccordion> 有自己管理的狀態,如果使用者有傳入則使用外部傳入的,沒有的話則使用元件本身的狀態。
const active = ref<string | number[]>(toArray(props.modelValue!));
const modelValueWritable = computed({
  get() {
    return active.value
  },
  set(value) {
    active.value = value;
    emit('update:modelValue', props.multiple ? value : value[0]);
  },
});
watch(() => props.modelValue, (value) => {
  active.value = toArray(value!);
});
接著我們提供一個 toggle 方法,讓到時候的 <AtomicAccordionPanel> 可以呼叫這個方法來切換展開狀態。
const toggle = (value: string | number) => {
  const index = active.value.findIndex((item) => item === value);
  if (props.multiple) {    
    if (index === -1) {
      modelValueWritable.value = [...active.value, value];
      return;
    }
    const filtered = [...active.value];
    filtered.splice(index, 1);
    modelValueWritable.value = filtered;
    return;
  }
  modelValueWritable.value = index === -1 ? [value] : [];
};
在這裡 <AtomicAccordion> 除了作為一個容器外,還有一個很重要的功能,就是要把 <AtomicAccordionPanel> 需要的狀態與方法同步提供給它們。要做到這一點我們在 <AtomicTabs> 那篇我提到可以使用 provide / inject。
// 寫在 <style lang="ts"> 內
export interface AtomicAccordionValue<T = any> {
  modelValue: Ref<T[]>;
  multiple: Readonly<Ref<boolean>>;
}
export const AtomicAccordionContext: InjectionKey<AtomicAccordionValue> = Symbol();
// 寫在 <style setup lang="ts"> 內
provide(AtomicAccordionContext, {
  modelValue: readonly(modelValueWritable),
  toggle,
})
<AtomicAccordionPanel>)在 <AtomicAccordionPanel> 中我們需要使用 inject 來取得 <AtomicAccordion> 提供的狀態與方法。
const context = inject<AtomicAccordionValue<T> | undefined>(
  AtomicAccordionContext,
  undefined
);
我們在 context 中有兩個屬性,modelValue 與 toggle,這樣我們就可以在 <AtomicAccordionPanel> 中分別算出當前的面板是否展開以及切換面板狀態。
const isActive = computed(() => {
  return context?.modelValue.value.includes(props.value);
});
const onSummaryClick = () => {
  context.toggle(props.value);
};
最後我們把這些狀態與方法套用到我們的模板中。
<template>
  <div
    class="atomic-accordion__details"
    :class="{ 'atomic-accordion__details--open': isActive }"
  >
    <button
      class="atomic-accordion__summary"
      type="button"
      @click="onSummaryClick"
    >
      <span class="atomic-accordion__title">
        <slot
          :active="isActive"
          name="summary"
        >
          {{ summary }}
        </slot>
      </span>
      <span class="atomic-accordion__marker">
        <ArrowSvg
          height="20"
          width="20"
        />
      </span>
    </button>
    <div
      v-show="isActive"
      class="atomic-accordion__content"
    >
      <slot name="default" />
    </div>
  </div>
</template>
樣式部分會依照不同專案的需求來處理,這邊提一下 Marker 的部分,Marker 作為表示 Panel 狀態的元素,我們期待 Marker 會隨著開啟或關閉的狀態有所變化,例如開啟時是向上的箭頭,關閉時是向下的箭頭。

所以當元件有 .atomic-accordion__details--open 這個 class 時,我們就將 Marker 旋轉 180 度。
.atomic-accordion {
  &__details--open &__marker {
    transform: rotate(180deg);
  }
}
觀察現在的 <AtomicAccordion> 元件,當我們嘗試切換它時會發現元件確實可以動作,但切換的過程非常生硬。

如果能加上一點過場動畫就太好了!
過場動畫的部分我們可以嘗試使用 Vue 的內建元件 <Transition> 來處理。
<Transition name="accordion-collapse-transition">
  <div
    v-show="isActive"
    class="atomic-accordion__content"
  >
    <slot name="default" />
  </div>
</Transition>
.accordion-collapse-transition {
  &-enter-active,
  &-leave-active {
    transition: all 300ms ease-in-out;
    overflow: hidden;
  }
  &-enter-from,
  &-leave-to {
    height: 0;
    padding-top: 0;
    padding-bottom: 0;
  }
}
嘗試過後會發現這個方法並不管用。

只有 padding 的部分有動畫,height 還是「啪、啪」的瞬間變化。受到了 CSS 特性所限制,因為當元素的高度是 auto 時,CSS 的 transition 是無法正確計算出高度變化的。
在這裡我們需要使用 JavaScript 來幫助我們計算出高度,這樣就可以讓過場動畫變得順暢。
因為我們是直接對 Content 的 DOM 進行操作,這表示我們需要捨棄使用
v-show指令來顯示與隱藏 Content,改用手動操作 DOM 的方式處理。
const contentRef = ref<HTMLElement>();
watch(isActive, (value) => {
  const content = contentRef.value;
  if (!content) return;
  if (value) {
    // 開啟動畫
  } else {
    // 關閉動畫
  }
})
我們需要直接對 Content 的 DOM 進行操作,首先我們處理開啟動畫。
首先,因為關閉時我們的 display 為 none,所以我們需要先將 display 設為 '',並且設定 overflow 為 hidden,這樣我們才能計算出正確的高度。
content.style.display = ''
content.style.overflow = 'hidden'
const height = content.scrollHeight
第二步,我們將 Content 的 height 跟 padding 都設定為 0,然後加上 transition 設定,這告訴瀏覽器我們要從 0px 開始做動畫。
content.style.height = '0';
content.style.paddingTop = '0';
content.style.paddingBottom = '0';
content.style.transition = 'all 300ms ease-in-out'
最後我們需要在瀏覽器的下一幀渲染後設定 height 的值,這樣瀏覽器才能正確地計算出高度變化。
requestAnimationFrame(() => {
  content.style.height = `${height}px`
})
這樣我們就完成了開啟動畫的部分。

動畫處理的主要流程可以分成三大步驟:
+-----------------+  +-----------------+  +-----------------+
|                 |  |                 |  |                 |
|                 |  |                 |  |                 |
|   設定起始狀態    |=>|    瀏覽器渲染     |=>|   設定結束狀態    |
|                 |  |                 |  |                 |
|                 |  |                 |  |                 |
+-----------------+  +-----------------+  +-----------------+
接著我們來處理關閉動畫。
watch(isActive, (value) => {
  // 略
  if (value) {
    // 開啟動畫
  } else {
    // 關閉動畫
    content.style.transition = 'all 300ms ease-in-out'
    content.style.height = `${content.scrollHeight}px`
    content.style.overflow = 'hidden'
    requestAnimationFrame(() => {
      content.style.height = '0'
      content.style.paddingTop = '0'
      content.style.paddingBottom = '0'
    })
  }
})
這樣關閉動畫也可以正常運作了。

最後做個小小的收尾,我們在動畫過程中直接在 DOM 上加上了 style,這些 style 在動畫結束後就沒有用了,我們需要在動畫結束後將這些 style 移除。
watch(isActive, (value) => {
  // 略
  const callback = () => {
    content.style.height = ''
    content.style.paddingTop = ''
    content.style.paddingBottom = ''
    content.style.overflow = ''
    content.style.transition = ''
    if (!value) content.style.display = 'none';
    content.removeEventListener('transitionend', callback)
  }
  content.addEventListener('transitionend', callback)
})
這樣我們就完成了 <AtomicAccordion> 的過場動畫。

最後的最後,因為我們是手動控制 DOM 的顯示與隱藏,在初始化的時候我們需要手動設定 Content 的 display。
const unWatch = watch(contentRef, (content) => {
  if (!content) return;
  content.style.display = !isActive.value ? 'none' : '';
  unWatch();
})
在這裡我們的 Summary 使用的 HTML 標籤為 <button>,所以我們並不需要特別設定 role 屬性。
但如果我們使用的是 <div> 或是其他非交互元素,我們需要設定 role="button"。我們可以參考 AtomicButton 裡面的介紹實作。
<div 
  role="button"
  tabindex="0"
  @click="onSummaryClick"
  @keydown.enter="onSummaryClick"
  @keydown.space="onSummaryClick"
>
  <span class="atomic-accordion__title">
    <!-- 標題 -->
  </span>
  <span class="atomic-accordion__marker">
    <!-- Marker -->
  </span>
</div>
Content 上則可以設定 role="region" 來幫助螢幕閱讀器更好的理解這個區塊的內容。
<div
  v-show="isActive"
  class="atomic-accordion__content"
  role="region"
>
  <!-- 內容 -->
</div>
role="region" 通常用於網頁中的一部分內容區域,這些區域需要被標記為有意義的區塊,可以讓屏幕閱讀器的使用者更清楚地知道該區域的存在。
true 表示展開,false 表示收合。id。id。<div
  class="atomic-accordion__details"
  :class="{ 'atomic-accordion__details--open': isActive }"
>
  <button
    :id="summaryId"
    :aria-expanded="isActive"
    :aria-controls="contentId"
  >
    <span class="atomic-accordion__title">
      <!-- 標題 -->
    </span>
    <span class="atomic-accordion__marker">
      <!-- Marker -->
    </span>
  </button>
  <div
    v-show="isActive"
    :aria-labelledby="summaryId"
    class="atomic-accordion__content"
    role="region"
    :id="contentId"
  >
    <!-- 內容 -->
  </div>
</div>
在開始實作 <AtomicAccordion> 之前,我們討論了選用 Accordion 或 Collapse 之間可能存在的差異,這可以作為團隊未來在討論元件命名時的參考,好的命名也有助於後續接手維護的人可以從名稱大略知道元件的特性。
在實作 <AtomicAccordion> 的過程中,我們使用的幾乎都是在前面章節有提到的技巧,像是我們可以使用 provide / inject 的技巧讓子元件可以取得父元件的狀態與方法;我們也在這裡複習了如果點擊的區塊不使用 <button> 而選用其他標籤實作時應該要注意的事項。
<AtomicAccordion> 元件本身的功能很簡單,我們另外也花了不少篇幅在說明如何實作過場動畫,在這個系列的最後我們可以再深入討論如何將這個動畫封裝成一個元件,讓其他元件也可以共用這個過場動畫。
<AtomicAccordion> 原始碼:AtomicAccordion.vue